# 使用 eventproxy 控制并发
30 秒速记
eventproxy= 事件聚合器:等几个异步结果都到齐了,再统一执行回调,解决回调嵌套- 两个主力:
ep.all('a', 'b', fn)等几个不同事件;ep.after('x', n, fn)等同一事件n次 - 它只负责「等齐」,不负责限流,请求早就一次全发出去了
- 失败要接住:
ep.fail(cb)或每个请求的错误也emit,不然少一次事件就永远等不齐 - 现在的等价写法:
Promise.all/Promise.allSettled+async/await
eventproxy 的作用是把几个并行的异步结果汇到一起,等它们全部完成再统一处理,省得把请求一层层嵌套在回调里。 用法是先说好等哪些事件,ep.all 或 ep.after,然后每个请求完成时 emit 一下,都到齐就触发回调。这其实就是 Promise.all 出现之前的写法,思路完全一样。很多人误会它能控制并发,其实不行,它只是等,请求在发出那一刻就全出去了;要限流还得配 async 的 mapLimit 这类调度工具。
理解「使用 eventproxy 控制并发」时,要先明确输入、运行阶段和可观察输出。这里的交互实验会分别执行正常路径与失败路径,并把 Node.js 的关键状态变化和同屏代码对应起来;不能只记命令名称。
工程中还要验证退出码、资源清理和异常输入。属于 event-loop 场景的配置应保持最小可复现,并在开发环境与 CI 中使用相同命令复跑,避免把平台差异或隐式默认值带到线上。
eventproxy 是早期 Node 社区为了解决「回调地狱」做的事件聚合库。核心只有两个动作:注册「等什么」,以及在异步操作完成时 emit。
const EventProxy = require('eventproxy');
const ep = new EventProxy();
// 等三个不同的数据源都到齐
ep.all('user', 'posts', 'config', (user, posts, config) => {
console.log(user.name, posts.length, config.theme);
});
ep.fail((err) => console.error('有一个失败了', err)); // 任一 ep.done 收到错误时触发
getUser(ep.done('user')); // ep.done 自动处理 (err, data) 回调:成功就 emit,失败就交给 fail
getPosts(ep.done('posts'));
getConfig(ep.done('config'));
换成今天的写法:
try {
const [user, posts, config] = await Promise.all([getUser(), getPosts(), getConfig()]);
console.log(user.name, posts.length, config.theme);
} catch (err) {
console.error('有一个失败了', err);
}
两者表达的东西一样,区别在错误处理:Promise.all 天然带错误传播,而 eventproxy 如果用的是裸 emit 又没在失败分支里处理,失败的那个事件永远不会来,回调就永远不执行,程序也不报错,这是用它时最常见的坑。所以读老代码时,看到 ep.emit 先确认每条失败路径都有着落。
面试官追问
追问 1用了 eventproxy,为什么还是一下子发出去几百个请求?
eventproxy 只管等事件到齐,什么时候发请求是你的循环决定的。forEach 里直接发,就是全部同时发。要限流得在发起那一侧加任务池。
追问 2脚本偶尔永远不退出,也不输出 JSON,最可能是什么原因?
有个请求失败了没 emit,ep.after 等的次数永远差一次。失败也要 emit(带上错误标记),或者用 ep.fail 统一接错误,再给每个请求加超时。
追问 3ep.all 和 Promise.all 有什么对应关系?
ep.all('a', 'b', fn) 约等于 Promise.all([pa, pb]).then(([a, b]) => fn(a, b))。区别是 Promise.all 有一个失败会直接 reject,而 eventproxy 不显式处理错误就只会卡住。
追问 4老项目里一堆 eventproxy,要不要全改成 Promise?
改动多、稳定的老代码不急着动。要改就先给现有行为写测试,覆盖成功、部分失败、超时,再一处处替换,保证错误处理和输出不变。
追问 5请求都结束了,进程却迟迟不退出,跟 eventproxy 有关吗?
一般不是它,是还有资源挂着,比如 keep-alive 连接、定时器。不过监听器挂在长期存在的对象上不解绑,也会一直持有回调里的上下文,用完记得 ep.unbind()。
# 《使用 eventproxy 控制并发》
# 目标
30 秒速记
- 任务:
node app.js输出CNode首页所有主题的title、href、comment1,JSON格式 - 两层请求:先抓首页拿到主题链接,再并发抓每个详情页取第一条评论
- 工具组合:
superagent发请求、cheerio解析HTML、eventproxy等所有详情页到齐 - 结果要靠
href对应,别依赖完成顺序;没评论给null,抓取失败要单独标记 - 结果走
stdout,调试日志走stderr,不然下游解析JSON会失败
这一节的需求是写一个小爬虫:抓 CNode 首页所有主题,再进每个主题页拿第一条评论,最后输出成 JSON 数组。 难点在第二步,几十个详情页请求是并行发出去的,什么时候全部回来不确定,所以要用 eventproxy 的 ep.after 等够次数再统一处理。每次 emit 时把 href 和页面内容一起带上,因为回来的顺序是乱的,不带 href 就对不上号。最后组装时没有评论的主题也要保留,comment1 给 null。
建立一个 lesson4 项目,在其中编写代码。
代码的入口是 app.js,当调用 node app.js 时,它会输出 CNode(https://cnodejs.org/ ) 社区首页的所有主题的标题,链接和第一条评论,以 json 的格式。
输出示例:
[
{
"title": "【公告】发招聘帖的同学留意一下这里",
"href": "http://cnodejs.org/topic/541ed2d05e28155f24676a12",
"comment1": "呵呵呵呵"
},
{
"title": "发布一款 Sublime Text 下的 JavaScript 语法高亮插件",
"href": "http://cnodejs.org/topic/54207e2efffeb6de3d61f68f",
"comment1": "沙发!"
}
]
面试官追问
追问 1输出里 title 和 comment1 偶尔对不上号,为什么?
按完成顺序往数组里塞结果,而请求回来的顺序是随机的。emit 时要带上 [href, html],组装时按 href 对应;要保持首页顺序,就按首页链接数组的下标回填。
追问 2抓取、解析、输出全写在一个回调里,怎么拆?
拆成三个函数:fetchTopicUrls() 返回链接数组,fetchFirstComment(url) 返回单条结果,main() 负责调度和输出。每个函数能单独测,页面结构变了也只改解析那一块。
追问 3任意一个详情页失败,是整体失败还是输出部分结果?
看下游要什么。要求必须完整就整体失败,退出码非 0;允许降级就输出成功项,失败项带上 error 字段,同时把失败数打到 stderr。最怕的是静默少几条。
追问 4把调试日志 console.log 了一堆,下游 JSON.parse 失败了,怎么改?
stdout 只放最终的 JSON,调试信息改用 console.error 打到 stderr。这样 node app.js > out.json 拿到的就是干净的数据。
# 挑战
30 秒速记
- 在上一题基础上,每条再加
author1(首评作者)和score1(作者积分) - 多了一层依赖:详情页 → 解析出作者 → 再请求作者主页拿积分,第三层请求要等第二层结果
- 同一个作者可能评论了多个主题,按作者缓存请求,避免重复抓
- 两层请求都要算进并发预算,只限第一层,第二层可能瞬间爆量
- 缺失要区分:没评论、作者页打不开、积分确实是
0,别统一填0
这个挑战把问题从「并行等齐」变成了「有依赖的两级请求」:拿到详情页才知道作者是谁,拿到作者才能去查积分。 写法上,每个主题内部是串行的,详情页完成后再请求作者页;不同主题之间还是并行。有两个容易忽略的点:一是首页上很多评论来自同一批活跃用户,按用户名缓存请求能省掉一大半;二是第二层请求也得限流,不然第一层限住了,第二层一口气冒出几十个请求,照样被封。
以上文目标为基础,输出 comment1 的作者,以及他在 cnode 社区的积分值。
示例:
[
{
"title": "【公告】发招聘帖的同学留意一下这里",
